昨天講了兩個問題和動機,今天把其中一個專案的骨架攤開來看——line-notion-bot。IG skill 的骨架和 deck.json,留到明天一起講。
line-notion-bot 的入口只有一個 fetch handler,收到 LINE 傳來的 webhook 之後做的事情很直接:
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('LINE → Notion bot is running.');
}
const body = await request.text();
const signature = request.headers.get('x-line-signature');
if (!(await verifySignature(env.LINE_CHANNEL_SECRET, body, signature))) {
return new Response('Unauthorized', { status: 401 });
}
let payload;
try {
payload = JSON.parse(body);
} catch {
return new Response('Bad Request', { status: 400 });
}
// 關鍵:立刻回 200,處理丟到背景,避免 LINE webhook 超時
ctx.waitUntil(handleEvents(payload.events || [], env));
return new Response('OK');
},
};
驗證簽章、解析 payload,然後在回 200 之前,並沒有先把「抓網頁內文、丟給 AI 分類、寫進 Notion」這一長串事情做完。真正的處理丟進 ctx.waitUntil(handleEvents(...)),Worker 已經回完 OK 之後,這個 promise 還在背景繼續跑。
原因是 LINE 這邊有自己的耐性上限。Messaging API 對 webhook 的回應有時間限制,太慢或沒回應,LINE 會判定失敗、重送同一個事件。抓一次網頁內文加一次 AI 分類,兩個網路請求疊在一起,很容易就超過這個限制。一旦被重送,同一則訊息可能被處理兩次——不是巧合踩到,是只要接了外部 API 就幾乎必然發生的事。所以流程上先把「收到了」這件事回覆掉,讓 LINE 那邊放心結案,剩下的慢慢做。
簽章驗證放在回 200 之前,是唯一沒有讓步的部分。沒帶正確簽章的請求,直接 401 打回去,不會進到後面任何處理邏輯。
ctx.waitUntil 丟進去的 handleEvents,先做一件事:檢查白名單。
async function handleEvents(events, env) {
for (const event of events) {
if (event.type !== 'message' || event.message?.type !== 'text') continue;
// 只服務你自己,避免任何加到好友的人都能用你的額度寫入你的 Notion
if (env.OWNER_USER_ID && event.source?.userId !== env.OWNER_USER_ID) {
continue;
}
await handleTextMessage(event, env);
}
}
OWNER_USER_ID 比對的是傳訊息的人是不是我自己,不是我就直接跳過、沒有任何回應。這個 Bot 本來就只打算給自己用,加好友的陌生訊息如果照樣觸發後面的 AI 呼叫和 Notion 寫入,額度會被別人用掉,Notion 裡也會混進不是我要的資料。這一關擋在所有處理邏輯之前,比對失敗連 log 都不用留。
通過白名單之後,handleTextMessage 才真正判斷這則訊息要做什麼,分成三條路:
有「指令關鍵字 + 網址」 → handleCommand(刪除/註記/分類/待辦)
沒有網址 → handleSearch(搜尋/說明/最近存的/待辦清單/依分類搜尋)
有網址(且不是指令) → 存筆記:fetchReadable → classify → createNotionPage
判斷順序是先看有沒有指令,再看有沒有網址。之所以要先判斷指令,是因為「待辦」這個詞本身也是搜尋關鍵字之一——單獨傳「待辦」會列出待辦清單,但傳「待辦 <連結>」是要把那篇筆記標記成待辦,兩者關鍵字重複但語意不同,只能靠有沒有網址跟著一起出現來分辨。
存筆記這條路是最長的一條:抓網頁內文、查詢既有標籤、丟給 AI 分類、寫進 Notion、回覆結果。中間任何一步都可能失敗,其中網頁內文用 try/catch 包了一層——Facebook、Instagram 這類擋爬蟲的平台常常抓不到內文,抓失敗不會讓整段流程整個失敗,改用一段固定文字頂替,讓 AI 至少能憑網址和我自己寫的備註分類,筆記還是存得下來。
一開始想這個 Bot 要存什麼,答案很單純:就是一些文字,標題、分類、標籤、摘要、連結。沒有複雜的關聯,也沒有什麼 CRUD 邏輯要維護,存進去、之後找得到、想刪就刪掉,僅此而已。資料量看起來也不會太大——這就是我平常自己會存的東西,不是要處理什麼大量資料的服務。為了這種需求特地架一個資料庫,感覺是殺雞用牛刀。
那時候第一個想到的是 Obsidian,畢竟本來就有在用它記東西。但 Obsidian 要免費、又要效能好,幾乎只能裝在本機跑,跟一個部署在 Cloudflare 上、隨時可能從手機觸發的 Bot 兜不起來,這條路很快就放棄了。
腦筋轉到 Notion,是因為它剛好卡在我要的位置:本來就拿來記事情,有資料庫欄位可以分類篩選,電腦手機都看得到,而且有現成的 API。與其自己另外接一個資料庫或 KV,不如直接把 Notion 當成這個 Bot 唯一的資料層——搜尋、判斷重複、列出最近存的、標記待辦,全部都是重新打一次 Notion API 去查,查完就丟,Worker 本身不留任何東西。
這個選擇也剛好跟 Cloudflare Workers 的特性合拍:Worker 執行完一次 request 什麼都不留,重開也不影響資料,因為資料從頭到尾都不在 Worker 裡。代價是查詢效能完全綁在 Notion API 上,例如它的 select/multi_select 篩選在傳入值不是既有選項時會直接回 400,逼得搜尋那段程式碼只能先撈一批資料再自己比對。目前這樣還算夠用,資料量真的堆到幾千筆的時候再說。
把上面兜起來:入口先回 200 讓 LINE 放心結案,白名單擋掉不是我自己的訊息,接著依內容分流到指令、搜尋、存筆記三條路,狀態全部丟給 Notion,Worker 本身從頭到尾不留任何東西。整支程式就是這麼一回事,沒有框架、沒有建置流程,一個檔案打完收工。
明天講 IG skill 的骨架:七個階段怎麼串起來,還有 deck.json 為什麼一定要有。